這裡是「Three.js學習日誌」的第24篇,這篇是在講解使用three.js + socket.io打造3D聊天室作品。這系列的文章假設讀者看得懂javascript,並且有Canvas 2D Context的相關知識。
今天我們要來接上昨天的進度!
按照預定今天是要把實體文本聊天室
的UI、還有canvas3D聊天室
的畫面都作出來~
首先還是先來展示一下今天的進度狀況~
我們在上一回製作了專案的大致外觀,不過實體文本聊天室
的UI、還有canvas3D聊天室
的部分都還沒有畫面。
所以我們今天的目標就是要把這兩樣東西做到至少有畫面~
我們今天只是要做畫面而已,所以這部分基本上只會改動html
、scss
。
scss的部分因為太佔篇幅,而且又不是本系列的重點,所以我們還是在這篇作品結束之後再公開
這部分其實沒什麼太大的障礙,就是單純的切版,這邊我們只擷取右側聊天室部分的html
,避免整篇貼上來太佔篇幅。
偷偷宣言一下,筆者小弟我在這部分比較支持老派的
BEM
CSS命名規則,BEM
讚!
<div class="wrapper__chat-block chat-block " id="chat-block">
<button class="chat-block__toggler" id="chat-block-toggler"></button>
<div class="chat-block__header user">
<div class="user__avatar">
<img src="~@img/avatar.png" alt="">
</div>
<div class="user__name">
Mizok
</div>
</div>
<div class="chat-block__body">
<div class="chat-block__body-inner chat-main">
<div class="chat-main__chat ">
<div class="chat-main__bubble">Hello There!</div>
</div>
<div class="chat-main__chat chat-main__chat--other">
<div class="chat-main__bubble">Hello There!</div>
</div>
</div>
</div>
<div class="chat-block__footer">
<div class="chat-block__input input-block">
<input type="text" id="txtInput" class="input-block__input" placeholder="Type something...">
<button class="input-block__button" id="sendTxt">
</button>
</div>
<div class="chat-block__author author">
<div class="author__former">
© Mizok.H
</div>
<div class="author__latter">
<a href="#" class="author__link">
<img src="~@img/github.svg" alt="">
</a>
<a href="#" class="author__link">
<img src="~@img/twitter.svg" alt="">
</a>
</div>
</div>
</div>
</div>
這邊筆者先來講講我自己的規劃~
因為我們的Cube
(中間的方塊) 一共有6個面。
假設忽略掉上下兩面,剩下每一面都放上聊天室的畫面,這樣感覺會非常的單調...
所以我的規劃是剩下的4個面分別放置不同的機能。
今天我們會先做出其中兩面,明天再做剩下的兩面。
我們今天要做的兩面,也就是:
聊天室
時鐘
這裡應該會是今天最困難的內容。
首先,我們的方塊上面版的部分其實是用three.js
的CSS3DRenderer
渲染出來的。
所謂的CSS3DRenderer
其實就是three.js
透過動態地去更改HTML
元素的transform
屬性,讓這個HTML
元素產生3D(透視)效果
。
CSS3DRenderer
會根據選用的camera
類型,來決定transform
HTML
元素的邏輯。
例如有沒有做圖像透視運算。
同時因為我們在這個專案上已經有使用了WebGLRenderer
,並且已經有了一個Scene實例,所以為了避免CSS3DRenderer
也跑去渲染原本的Scene,這邊我們的做法會像是這樣:
WebGLRenderer
用來渲染的canvas
,還有CSS3DRenderer
用來transform
的HTML
元素,用CSS
position:absolute
的方式疊合在一起。為了達成上面這個架構,我把資料夾的目錄做了一點修改,變成這樣:
另外在這幾隻檔案做了修改:
./src/ts/class/base.ts
./src/ts/class/cube.ts
./src/ts/class/renderer.ts
./src/ts/class/playground.ts
並新增了
./src/ts/class/dom-cube.ts
./src/ts/dom/chat.ts
./src/ts/dom/clock.ts
這邊我們先來講講修改的部分,接著再講新增的部分
./src/ts/class/base.ts
export class Base {
sizer = new Sizer(this.canvas)
scene = new Scene();
scene2 = new Scene(); // 加入了Scene2
ticker = new Ticker();
camera = new Camera(this);
renderer = new Renderer(this);
playground = new Playground(this);
touched = false;
touchedReactDelay = 1000;
resources: {
[key: string]: any
}
// 這邊新增傳入了兩個HTMLElement,分別是domCanvas和domBundle,之後會提到
constructor(public canvas: HTMLCanvasElement, public domCanvas: HTMLElement, public domBundle: HTMLElement) {
this.initResizeMechanic();
this.initTickMechanic();
this.initTouchMechanic();
}
//...
//...
//... 中間部分因為沒有變更,跟之前一樣,所以省略
initTickMechanic() {
this.ticker.on('tick', (clock: Clock) => {
//這邊我原本是把clock整個直接傳給playground作為參數
//現在改成只傳送幀間時差
const delta = clock.getDelta();
this.renderer.update();
this.camera.update();
this.playground.update(delta);
})
}
// 這邊我順手實作了當滑鼠點擊的時候暫時停止方塊旋轉的邏輯,
// 不然使用者主動旋轉方塊時還讓方塊一直自旋的話UX體驗會很糟糕XD
initTouchMechanic() {
let startLocation = new Vector2();
let endLocation = new Vector2();
let timeout: any;
const cbStart = (e: MouseEvent) => {
startLocation.x = e.clientX;
startLocation.y = e.clientY;
this.touched = true;
}
const cbEnd = (e: MouseEvent) => {
endLocation.x = e.clientX;
endLocation.y = e.clientY;
//如果按下跟提起的座標相距不遠,那就不暫停旋轉,反之則暫停一秒
const delay = startLocation.distanceTo(endLocation) > 10 ? this.touchedReactDelay : 0;
clearTimeout(timeout)
timeout = setTimeout(() => {
this.touched = false;
}, delay)
}
this.domCanvas.addEventListener('mousedown', cbStart)
this.domCanvas.addEventListener('touchstart', cbStart)
this.domCanvas.addEventListener('mouseup', cbEnd)
this.domCanvas.addEventListener('touchend', cbEnd)
this.domCanvas.addEventListener('mouseleave', cbEnd)
}
//...
}
在./src/ts/class/base.ts
中,主要調整的地方大概如下:
domCanvas
其實是一個DIV
,它是後面我們用來傳給CSS3DRenderer
作為參數用的,而domBundle
也是一個DIV
,它裡面放置了四個方塊面板的Html
元素,我們之後會再講到怎麼使用它。
除了新增了剛剛提到的Scene2之外,還補上了一段「當滑鼠點擊的時候暫時停止方塊旋轉」的邏輯,主要是因為我想到使用者主動旋轉方塊時還讓方塊一直自旋的話,可能體感不是很好XD
initTickMechanic
的傳參這邊原本我們是把clock
整個直接傳給playground
作為update
方法的參數,而現在我改成只傳送幀間時差(delta)。
原因主要是因為:
現在我們有「當滑鼠點擊的時候暫時停止方塊旋轉」的邏輯,而如果這邊我們保持用原本的getElapsedTime
去計算旋轉方塊的角度,當方塊旋轉被暫停的時候,getElapsedTime
回傳的值還是會繼續增加,導致方塊在結束暫停的時候會直接瞬間跳轉到奇怪的角度。
所以這個地方我改成傳送幀間時差(delta),讓方塊旋轉的邏輯變成是每一帧去增加角度(+=delta/5),這樣就不會出現角度跳轉的問題。
./src/ts/class/cube.ts
export class Cube implements MeshType {
mesh: Mesh;
group: Group;
ready = false;
constructor(private base: Base) {
this.setModel();
}
//...
//...
//... 中間部分因為沒有變更,跟之前一樣,所以省略
doAnimation() {
gsap.to(this.mesh.rotation, {
x: 0,
y: -Math.PI / 2,
//這邊我稍微調整了一下旋轉的角度,
//主要是希望可以開場看到聊天室畫面
z: 0,
duration: 1,
paused: true
}).play()
gsap.to(this.mesh.scale, {
x: 1,
y: 1,
z: 1,
duration: 2,
paused: true,
onComplete: () => {
this.ready = true;
}
}).play()
}
update(delta: number) {
//這邊就是我們剛剛提到的改成以delta來計算旋轉
if (!this.base.touched) {
this.group.rotation.y += delta / 5;
}
}
}
基本上./src/ts/class/cube.ts
的部分沒什麼特別的,簡單來說就是我們剛剛提到過的把旋轉的計算方式改成用「+=delta/5」的方式來實作。
./src/ts/class/renderer.ts
export class Renderer {
instance: WebGLRenderer;
instance2: CSS3DRenderer;//加入了CSS3DRenderer作為第二實例
private sizer = this.base.sizer;
private canvas = this.base.canvas;
private domCanvas = this.base.domCanvas;
private scene = this.base.scene;
private scene2 = this.base.scene2;//加入了Scene2
private camera = this.base.camera;
constructor(
private base: Base
) {
this.setInstances()
}
setInstances() {
//instance
this.instance = new WebGLRenderer({
canvas: this.canvas,
antialias: true
})
this.instance.physicallyCorrectLights = true
this.instance.toneMappingExposure = 1.75
this.instance.shadowMap.enabled = true
this.instance.shadowMap.type = PCFSoftShadowMap
this.instance.setClearColor(0xffffff)
//instance2
this.instance2 = new CSS3DRenderer({
//我們這邊把剛剛提到的domCanvas傳進去
//這樣等下我們add進來的物件都會被放置在這個domCanvas底下
element: this.domCanvas
});
this.sizing();
}
resize() {
this.sizing();
}
sizing() {
//instance
this.instance.setSize(this.sizer.width, this.sizer.height);
this.instance.setPixelRatio(this.sizer.pixelRatio);
//instance2
// 發動CSS3DRenderer的setSize方法
this.instance2.setSize(this.sizer.width, this.sizer.height);
}
update() {
this.instance.render(this.scene, this.camera.instance);
// 發動CSS3DRenderer的render方法
this.instance2.render(this.scene2, this.camera.instance);
}
}
在./src/ts/class/renderer.ts
中我修改的部分就是生成CSS3DRenderer
的實例,並且統一發動CSS3DRenderer
和WebGLRenderer
的render
/setSize
方法。
./src/ts/class/playground.ts
export class Playground {
env: Env;
cube: Cube;
domCube: DomCube;
ready = false;
constructor(private base: Base) {
this.init();
}
init() {
this.base.getResources().then(() => {
this.env = new Env(this.base);
this.cube = new Cube(this.base);
this.domCube = new DomCube(this.base);
this.ready = true;
})
}
update(delta: number) {
if (this.ready) {
this.env.update(delta);
this.cube.update(delta);
this.domCube.update(delta);
}
}
}
./src/ts/class/playground.ts
其實也沒啥特別的,就是把DomCube
的實例加進去,並且同步發動env
/cube
/domCube
的update
方法。
./src/ts/class/dom-cube.ts
import { Group } from "three";
import { Base } from "./base";
import { Chat } from '../dom';
import gsap from "gsap";
import { Clock } from "../dom/clock";
export class DomCube {
chat: Chat;
clock: Clock;
groupOuter = new Group();
groupInner = new Group();
ready = false;
constructor(private base: Base) {
this.init();
}
init() {
this.chat = new Chat(this.base);
this.clock = new Clock(this.base);
this.groupInner.scale.set(0, 0, 0);
this.groupInner.rotation.set(Math.PI / 3, Math.PI / 3, Math.PI / 3);
this.groupInner.add(this.chat.object); //加入chat面板
this.groupInner.add(this.clock.object); //加入clock面板
this.groupOuter.add(this.groupInner);
//利用兩層group來模仿Cube的旋轉動畫
this.base.scene2.add(this.groupOuter);
this.doAnimation();
}
doAnimation() {
gsap.to(this.groupInner.rotation, {
x: 0,
y: -Math.PI / 2, //這邊參數都跟cube 一樣
z: 0,
duration: 1,
paused: true
}).play()
gsap.to(this.groupInner.scale, {
x: 1,
y: 1,
z: 1,
duration: 2,
paused: true,
onComplete: () => {
this.ready = true;
}
}).play()
}
update(delta: number) {
if (!this.base.touched) {
this.groupOuter.rotation.y += delta / 5;
}
this.chat.update();
this.clock.update();
}
}
這邊DomCube
的概念有點像是用Group
假造一個虛擬的cube,它也同樣有外層旋轉和內層旋轉的作動方式,讓DomCube
看起來好像是黏在Cube
上面一起旋轉。
但實際上DomCube
是HTML
元素,而Cube
則是Canvas
。
老實說這邊應該可以改成讓domCube extends Cube,畢竟方法有重複的,不過我後來想想決定還是先等後續優化階段再說吧 :P
./src/ts/dom/chat.ts
import { Object3D, Vector3 } from "three";
import { CSS3DObject } from "three/examples/jsm/renderers/CSS3DRenderer";
import { Base } from "../class/base";
export class Chat {
object: Object3D
element: HTMLElement
private offset = 1.7; // offset 其實就是cube的邊長/2
private pos = new Vector3(-this.offset, 0, 0);
private normal = new Vector3(-1, 0, 0);
private cNormal = new Vector3();
private cPos = new Vector3();
private m4 = new Matrix4();
constructor(private base: Base) {
this.setElement();
}
setElement() {
this.element = this.base.domBundle.querySelector('#chat-main');
this.object = new CSS3DObject(this.element);
this.object.position.set(0, 0, this.offset);
this.object.scale.set(1 / 160, 1 / 160, 1);
//160的縮放比例是憑感覺抓的
}
// 這邊這個算法其實蠻重要的,主要是為了要在面板轉到背面的時候隱藏它
update() {
this.cNormal.copy(this.normal).applyMatrix3(this.base.playground.cube.mesh.normalMatrix); this.cPos.copy(this.pos).applyMatrix4(this.m4.multiplyMatrices(this.base.camera.instance.matrixWorldInverse, this.base.playground.cube.mesh.matrixWorld));
let d = this.cPos.negate().dot(this.cNormal);
this.element.style.visibility = d < 0 ? "hidden" : "visible";
}
}
在目前這個階段,./src/ts/dom/chat.ts
和等下要介紹的./src/ts/dom/clock.ts
其實很像。
這邊的邏輯其實就是把HTML
元素,利用CSS3DObject
這個類,包裝成CSS3DObject
物件,這樣我們就可以加進去位於dom-cube
的group
裡面,讓所有的面板一起被渲染。
在./src/ts/dom/chat.ts
還有一個重要的段落就是update
方法,這邊我還實作了「當面板轉到背面的時候將該元素透明度訂為0」。
這邊如果沒有偵測背面並隱藏的話就會有這種問題
「當面板轉到背面的時候將該元素透明度訂為0」這邊的算法其實是從這篇討論區文章來的,必須要感謝一下@prisoner849這位網友,這是他第二次被他救了QQ。
這邊「當面板轉到背面的時候將該元素透明度訂為0」的算法程式碼,筆者有在2022.10.16做過修改,主要是因為原本的作法有點瑕疵,如對觀眾朋友或評審們造成困擾,深感抱歉!
./src/ts/dom/clock.ts
import { Object3D, Vector3 } from "three";
import { CSS3DObject } from "three/examples/jsm/renderers/CSS3DRenderer";
import { Base } from "../class/base";
export class Clock {
object: Object3D
element: HTMLElement
private offset = 1.7;
private pos = new Vector3(-this.offset, 0, 0);
private normal = new Vector3(-1, 0, 0);
private cNormal = new Vector3();
private cPos = new Vector3();
private m4 = new Matrix4();
constructor(private base: Base) {
this.setElement();
}
setElement() {
this.element = this.base.domBundle.querySelector('#clock');
this.object = new CSS3DObject(this.element);
this.object.position.set(-this.offset, 0, 0);
this.object.rotation.y = - Math.PI / 2;
this.object.scale.set(1 / 160, 1 / 160, 1 / 160);
}
update() {
this.cNormal.copy(this.normal).applyMatrix3(this.base.playground.cube.mesh.normalMatrix); this.cPos.copy(this.pos).applyMatrix4(this.m4.multiplyMatrices(this.base.camera.instance.matrixWorldInverse, this.base.playground.cube.mesh.matrixWorld));
let d = this.cPos.negate().dot(this.cNormal);
this.element.style.visibility = d < 0 ? "hidden" : "visible";
}
}
./src/ts/dom/clock.ts
在現階段其實就跟./src/ts/dom/chat.ts
差不多,所以我們就不特別介紹~
今天實作完了大部分的外觀(還有一小部分沒處理),明天我們除了會處理剩下的外觀,還會開始著手處理Socket.io
連線的部分,希望各位可以繼續追蹤~